<?php

namespace Elgg\Database;

use Doctrine\DBAL\Query\Expression\CompositeExpression;
use Elgg\Database\Clauses\AnnotationWhereClause;
use Elgg\Database\Clauses\EntityWhereClause;
use Elgg\Database\Clauses\MetadataWhereClause;
use Elgg\Database\Clauses\RelationshipWhereClause;
use Elgg\Exceptions\DomainException;
use Elgg\Exceptions\LogicException;

/**
 * Entities repository contains methods for fetching entities from database or performing
 * calculations on entity properties.
 *
 * @internal
 */
class Entities extends Repository {

	/**
	 * {@inheritdoc}
	 */
	public function count() {
		$qb = Select::fromTable(EntityTable::TABLE_NAME, EntityTable::DEFAULT_JOIN_ALIAS);

		$count_expr = $this->options->distinct ? "DISTINCT {$qb->getTableAlias()}.guid" : '*';
		$qb->select("COUNT({$count_expr}) AS total");

		$qb = $this->buildQuery($qb);

		$result = _elgg_services()->db->getDataRow($qb);

		if (empty($result)) {
			return 0;
		}
		
		return (int) $result->total;
	}

	/**
	 * Performs a mathematical calculation on a set of entity properties
	 *
	 * @param string $function      Valid numeric function
	 * @param string $property      Property name
	 * @param string $property_type 'attribute'|'metadata'|'annotation'
	 *
	 * @return string
	 * @throws DomainException
	 */
	public function calculate($function, $property, $property_type = null) {
		if (!in_array(strtolower($function), QueryBuilder::CALCULATIONS)) {
			throw new DomainException("'{$function}' is not a valid numeric function");
		}

		if (!isset($property_type)) {
			if (in_array($property, \ElggEntity::PRIMARY_ATTR_NAMES)) {
				$property_type = 'attribute';
			} else {
				$property_type = 'metadata';
			}
		}

		$qb = Select::fromTable(EntityTable::TABLE_NAME, EntityTable::DEFAULT_JOIN_ALIAS);

		switch ($property_type) {
			case 'attribute':
				if (!in_array($property, \ElggEntity::PRIMARY_ATTR_NAMES)) {
					throw new DomainException("'{$property}' is not a valid attribute");
				}

				$qb->addSelect("{$function}({$qb->getTableAlias()}.{$property}) AS calculation");
				break;

			case 'metadata':
				$alias = $qb->joinMetadataTable($qb->getTableAlias(), 'guid', $property, 'inner', MetadataTable::DEFAULT_JOIN_ALIAS);
				$qb->addSelect("{$function}({$alias}.value) AS calculation");
				break;

			case 'annotation':
				$alias = $qb->joinAnnotationTable($qb->getTableAlias(), 'guid', $property, 'inner', AnnotationsTable::DEFAULT_JOIN_ALIAS);
				$qb->addSelect("{$function}({$alias}.value) AS calculation");
				break;
		}

		$qb = $this->buildQuery($qb);

		$result = _elgg_services()->db->getDataRow($qb);

		// do not cast data as calculations could be used as int, float or string
		return $result->calculation;
	}

	/**
	 * Fetch entities
	 *
	 * @param int      $limit    Limit
	 * @param int      $offset   Offset
	 * @param callable $callback Custom callback
	 *
	 * @return \ElggEntity[]
	 */
	public function get($limit = null, $offset = null, $callback = null) {
		$qb = Select::fromTable(EntityTable::TABLE_NAME, EntityTable::DEFAULT_JOIN_ALIAS);

		$distinct = $this->options->distinct ? 'DISTINCT ' : '';
		$qb->select("{$distinct}{$qb->getTableAlias()}.*");

		$this->expandInto($qb, $qb->getTableAlias());

		$qb = $this->buildQuery($qb);

		// add default ordering
		$original_order = elgg_extract('order_by', $this->options->__original_options);
		if (empty($this->options->order_by) && $original_order !== false) {
			$qb->addOrderBy("{$qb->getTableAlias()}.time_created", 'desc');
			// also add order by guid, to rely less on internals of MySQL fallback ordering
			$qb->addOrderBy("{$qb->getTableAlias()}.guid", 'desc');
		}

		if ($limit > 0) {
			$qb->setMaxResults((int) $limit);
			$qb->setFirstResult((int) $offset);
		}

		$options = $this->options->getArrayCopy();

		$options['limit'] = (int) $limit;
		$options['offset'] = (int) $offset;
		$options['callback'] = $callback ?: $this->options->callback;
		if (!isset($options['callback'])) {
			$options['callback'] = [_elgg_services()->entityTable, 'rowToElggStar'];
		}

		unset($options['count']);

		return _elgg_services()->entityTable->fetch($qb, $options);
	}
	
	/**
	 * Returns a list of months in which entities were updated or created.
	 *
	 * @tip     Use this to generate a list of archives by month for when entities were added or updated.
	 *
	 * @warning Months are returned in the form YYYYMM.
	 *
	 * @return array An array months as YYYYMM
	 */
	public function getDates(): array {
		$qb = Select::fromTable(EntityTable::TABLE_NAME, EntityTable::DEFAULT_JOIN_ALIAS);

		$qb->select("DISTINCT EXTRACT(YEAR_MONTH FROM FROM_UNIXTIME({$qb->getTableAlias()}.time_created)) AS yearmonth");

		$this->expandInto($qb, $qb->getTableAlias());

		$qb = $this->buildQuery($qb);

		$options = $this->options->getArrayCopy();
		unset($options['count']);
		
		$options['callback'] = false;
		
		$results = _elgg_services()->entityTable->fetch($qb, $options);
		
		if (empty($results)) {
			return [];
		}
	
		return array_map(function ($e) {
			return $e->yearmonth;
		}, $results);
	}

	/**
	 * Execute the query resolving calculation, count and/or batch options
	 *
	 * @return array|\ElggData[]|\ElggEntity[]|int|\ElggBatch
	 * @throws LogicException
	 */
	public function execute() {
		if ($this->options->annotation_calculation) {
			$clauses = $this->options->annotation_name_value_pairs;
			if (count($clauses) > 1 && $this->options->annotation_name_value_pairs_operator !== 'OR') {
				throw new LogicException('Annotation calculation can not be performed on multiple annotation name value pairs merged with AND');
			}

			$clause = array_shift($clauses);

			return $this->calculate($this->options->annotation_calculation, $clause->names, 'annotation');
		} else if ($this->options->metadata_calculation) {
			$clauses = $this->options->metadata_name_value_pairs;
			if (count($clauses) > 1 && $this->options->metadata_name_value_pairs_operator !== 'OR') {
				throw new LogicException('Metadata calculation can not be performed on multiple metadata name value pairs merged with AND');
			}

			$clause = array_shift($clauses);

			return $this->calculate($this->options->metadata_calculation, $clause->names, 'metadata');
		} else if ($this->options->count) {
			return $this->count();
		} else if ($this->options->batch) {
			return $this->batch($this->options->limit, $this->options->offset, $this->options->callback);
		} else {
			return $this->get($this->options->limit, $this->options->offset, $this->options->callback);
		}
	}

	/**
	 * Build a database query
	 *
	 * @param QueryBuilder $qb the Elgg QueryBuilder
	 *
	 * @return QueryBuilder
	 */
	protected function buildQuery(QueryBuilder $qb) {
		$ands = [];

		foreach ($this->options->joins as $join) {
			$join->prepare($qb, $qb->getTableAlias());
		}

		foreach ($this->options->wheres as $where) {
			$ands[] = $where->prepare($qb, $qb->getTableAlias());
		}

		$ands[] = $this->buildEntityClause($qb);
		$ands[] = $this->buildPairedMetadataClause($qb, $this->options->metadata_name_value_pairs, $this->options->metadata_name_value_pairs_operator);
		$ands[] = $this->buildPairedMetadataClause($qb, $this->options->search_name_value_pairs, 'OR');
		$ands[] = $this->buildPairedAnnotationClause($qb, $this->options->annotation_name_value_pairs, $this->options->annotation_name_value_pairs_operator);
		$ands[] = $this->buildPairedRelationshipClause($qb, $this->options->relationship_pairs);

		$ands = $qb->merge($ands);

		if (!empty($ands)) {
			$qb->andWhere($ands);
		}

		return $qb;
	}

	/**
	 * Process entity attribute wheres
	 * Applies entity attribute constrains on the selected entities table
	 *
	 * @param QueryBuilder $qb Query builder
	 *
	 * @return \Closure|CompositeExpression|mixed|null|string
	 */
	protected function buildEntityClause(QueryBuilder $qb) {
		return EntityWhereClause::factory($this->options)->prepare($qb, $qb->getTableAlias());
	}

	/**
	 * Process metadata name value pairs
	 * Joins the metadata table on entity guid in the entities table and applies metadata where clauses
	 *
	 * @param QueryBuilder          $qb      Query builder
	 * @param MetadataWhereClause[] $clauses Where clauses
	 * @param string                $boolean Merge boolean
	 *
	 * @return CompositeExpression|string
	 */
	protected function buildPairedMetadataClause(QueryBuilder $qb, $clauses, $boolean = 'AND') {
		$parts = [];

		foreach ($clauses as $clause) {
			if ($clause instanceof MetadataWhereClause) {
				if (strtoupper($boolean) === 'OR' || count($clauses) === 1) {
					$joined_alias = $qb->joinMetadataTable($qb->getTableAlias(), 'guid', null, 'inner', MetadataTable::DEFAULT_JOIN_ALIAS);
				} else {
					$joined_alias = $qb->joinMetadataTable($qb->getTableAlias(), 'guid', $clause->names);
				}
				
				$parts[] = $clause->prepare($qb, $joined_alias);
			}
		}

		return $qb->merge($parts, $boolean);
	}

	/**
	 * Process annotation name value pairs
	 * Joins the annotation table on entity guid in the entities table and applies annotation where clauses
	 *
	 * @param QueryBuilder            $qb      Query builder
	 * @param AnnotationWhereClause[] $clauses Where clauses
	 * @param string                  $boolean Merge boolean
	 *
	 * @return CompositeExpression|string
	 */
	protected function buildPairedAnnotationClause(QueryBuilder $qb, $clauses, $boolean = 'AND') {
		$parts = [];

		foreach ($clauses as $clause) {
			if (strtoupper($boolean) === 'OR' || count($clauses) === 1) {
				$joined_alias = $qb->joinAnnotationTable($qb->getTableAlias(), 'guid', null, 'inner', AnnotationsTable::DEFAULT_JOIN_ALIAS);
			} else {
				$joined_alias = $qb->joinAnnotationTable($qb->getTableAlias(), 'guid', $clause->names);
			}
			
			$parts[] = $clause->prepare($qb, $joined_alias);
		}

		return $qb->merge($parts, $boolean);
	}

	/**
	 * Process relationship pairs
	 *
	 * @param QueryBuilder              $qb      Query builder
	 * @param RelationshipWhereClause[] $clauses Where clauses
	 * @param string                    $boolean Merge boolean
	 *
	 * @return CompositeExpression|string
	 */
	protected function buildPairedRelationshipClause(QueryBuilder $qb, $clauses, $boolean = 'AND') {
		$parts = [];

		foreach ($clauses as $clause) {
			if (strtoupper($boolean) === 'OR' || count($clauses) === 1) {
				$joined_alias = $qb->joinRelationshipTable($qb->getTableAlias(), $clause->join_on, null, $clause->inverse, 'inner', RelationshipsTable::DEFAULT_JOIN_ALIAS);
			} else {
				$joined_alias = $qb->joinRelationshipTable($qb->getTableAlias(), $clause->join_on, $clause->names, $clause->inverse);
			}
			
			$parts[] = $clause->prepare($qb, $joined_alias);
		}

		return $qb->merge($parts, $boolean);
	}
}
